在異動網站的敏感性資料時,我們常常會被要求能夠詳細的紀錄資料被異動的歷程,而如果是分別在Repository中去實作更動敏感資料時,必須要加上稽核紀錄的功能,很容易會發生新功能沒加到的情況,當日後要撈資料出來查看時就會發現稽核紀錄的完整性不足夠。因此如果可以統一在底層讓所有物件執行新增、修改時自動檢查是否該欄位需要稽核,需要就自動記錄還是個比較一勞永逸的方法,今天就要來向大家介紹如何使用EFHook實現稽核的功能。
註: 實際上稽核的應用場景依照各自公司的習慣不同,可能會有很多種實作的方法,今天介紹的做法可以算是一個基本雛形,大家可以理解後依照各自的使用需求做更進一步的調整。
大家可以從Github ApiSample - Tag Day08開始練習今天的功能
※實現原理
我們將會透過EFHook,在所有資料新增或更新前進行判斷,判斷欲進行資料更新的Data Class欄位,是否有被標記為需要稽核,如果需要稽核就會同時產生一筆AuditLog寫入資料庫。
這樣做的好處是,我們可以直接透過觀看Data Class包含的Attribute就知道它應該具有哪些功能,而如果萬一我們需要增加稽核的欄位也可以很簡單的就加到新的欄位上。
註: Data Class就是Tables專案中代表資料表的物件(例如: Category)

※什麼是Metadata?
在Entity Framework中我們很常使用DataAnnotation的方式來實現一些功能,例如產生資料表時,我們會在Id標記Key Attribute來代表它是主鍵,在資料驗證的時候,我們也會透過例如StringLength(30)這種屬性來代表該屬性的資料長度限制。

除了直接在Data Class上標記Data Annotation的方式之外,C#還提供了一種叫做MetaDataType的方式,讓我們在使用像是Database First的Entity Framework時,由於Data Class是由T4所產生的,而且每次變更資料結構就會需要產生,若是我們直接標記在Data Class上會造成每次更新都要重標,所以透過Partial Class和Metadata的方式,將這些Attributes標記在一個影分身Class(MetaDataType)上,這樣系統就可以直接從它的Metadata Type上讀取Data Annotation的資訊。

延伸閱讀:
* Code First Data Annotations
※實做AuditLog
接下來向大家介紹如何實做AuditLog的功能
在Hooks新增AuditLog.cs, 也是我們稽核紀錄所需要的欄位
public class AuditLog : IIdentifiable
{
public int Id { get; set; }
public Guid IdentifyKey { get; set; }
public string IdentifyName { get; set; }
public string OriginValue { get; set; }
public string NewValue { get; set; }
public DateTime CreatedAt { get; set; }
public string CreatedBy { get; set; }
}
新增IAuditableContext介面,為了降低耦合性,我們在Hooks中不會直接對DbContext的實體作操作,而是利用這個介面來寫入稽核紀錄,並讓ShopContext實做這個介面
public interface IAuditableContext
{
IDbSet AuditLogs { get; set; }
}
3. 新增RequireAuditAttribute,用來加在需要稽核的欄位上
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
public sealed class RequireAuditAttribute : Attribute
{
public RequireAuditAttribute()
{
}
}
新增IIdentifiable介面,讓我們可以比較簡單建立資料表與AuditLog之間的關聯,EntityBase會繼承這個介面
public interface IIdentifiable
{
Guid IdentifyKey { get; set; }
}
實做AuditLogPreInsertHook
public class AuditLogPreInsertHook : PreInsertHook
{
public HttpContextBase HttpContext { get; set; }
public AuditLogPreInsertHook(HttpContextBase httpContext)
{
    this.HttpContext = httpContext;
}
public override void Hook(IIdentifiable entity, HookEntityMetadata metadata)
{
    //// Check is auditable context, contains auditlog table
    IAuditableContext context = metadata.CurrentContext as IAuditableContext;
    if (context == null)
    {
         return;
    }
    //// Get current username
    var userName = "Unlogin";
    if (this.HttpContext != null)
    {
        userName = this.HttpContext.User.Identity.Name;
    }
    //// Get entity type and associate etadata
    var entityType = entity.GetType();
    TypeDescriptor.AddProvider(new AssociatedMetadataTypeTypeDescriptionProvider(entityType), entityType);
    //// Get is entity contains requireAudit Field, and add auditlog
    var properties = TypeDescriptor.GetProperties(entityType);
    foreach (PropertyDescriptor propertyDescriptor in properties)
    {
        //// If contains requireAudit attribute, add audit log
        var propRequireAudit = propertyDescriptor.Attributes.OfType<RequireAuditAttribute>().FirstOrDefault();
        if (propRequireAudit == null)
        {
             continue;
        }
        AuditLog auditLog = new AuditLog();
        auditLog.IdentifyKey = entity.IdentifyKey;
        auditLog.IdentifyName = entityType.Name;
        auditLog.OriginValue = string.Empty;
        auditLog.NewValue = propertyDescriptor.GetValue(entity).ToString();
        auditLog.CreatedAt = DateTime.Now;
        auditLog.CreatedBy = userName;
        context.AuditLogs.Add(auditLog);
     }
}
public override bool RequiresValidation
{
    get { return false; }
}
}
實作AuditLogPreUpdateHook
public class AuditLogPreUpdateHook : PreUpdateHook
{
public HttpContextBase HttpContext { get; set; }
public AuditLogPreUpdateHook(HttpContextBase httpContext)
{
    this.HttpContext = httpContext;
}
public override void Hook(IIdentifiable entity, HookEntityMetadata metadata)
{
     //// Check is auditable context, contains auditlog table
     IAuditableContext context = metadata.CurrentContext as IAuditableContext;
     if (context == null)
     {
          return;
     }
     //// Get current username
     var userName = "Unlogin";
     if (this.HttpContext != null)
     {
          userName = this.HttpContext.User.Identity.Name;
     }
     //// Get entry, entity type and associate etadata
     var entry = ((IObjectContextAdapter)metadata.CurrentContext).ObjectContext.ObjectStateManager.GetObjectStateEntry(entity);
     var entityType = entity.GetType();
     TypeDescriptor.AddProvider(new AssociatedMetadataTypeTypeDescriptionProvider(entityType), enti  tyType);
     //// Get is entity modified property contains requireAudit Field, and add auditlog
     var properties = TypeDescriptor.GetProperties(entityType);
     foreach (string propertyName in entry.GetModifiedProperties())
     {
         //// Check is property need io
         var propertyDescriptor = properties.Find(propertyName, true);
         var propRequireAudit = propertyDescriptor.Attributes.OfType<RequireAuditAttribute>().F irstOrDefault();
         if (propRequireAudit == null)
         {
              continue;
         }
         //// Get original value
         DbDataRecord original = entry.OriginalValues;
         string oldValue = original.GetValue(original.GetOrdinal(propertyName)).ToString();
         //// Get new value
         CurrentValueRecord current = entry.CurrentValues;
         string newValue = current.GetValue(current.GetOrdinal(propertyName)).ToString();
         //// Write Audit Log
         AuditLog auditLog = new AuditLog();
         auditLog.IdentifyKey = entity.IdentifyKey;
         auditLog.IdentifyName = entityType.Name;
         auditLog.OriginValue = oldValue;
         auditLog.NewValue = newValue;
         auditLog.CreatedAt = DateTime.Now;
         auditLog.CreatedBy = userName;
         context.AuditLogs.Add(auditLog);
    }
}
public override bool RequiresValidation
{
    get { return false; }
}
}
延伸閱讀:
* 取得欄位的資料標記 Data Annotation from Metadata Class
※測試AuditLog是否生效
在需要稽核的欄位上加上RequeireAudit標記
public class Category : EntityBase
{
[StringLength(100)]
[Required]
[RequireAudit]
public string Name { get; set; }
}
public class Product : EntityBase
{
[RequireAudit]
public decimal Price { get; set; }
[RequireAudit]
public decimal Cost { get; set; }
}
在Hooks.Test增加稽核欄位新增或更新時自動寫入紀錄功能.feature
#language: zh-TW
功能: 稽核欄位新增或更新時自動寫入紀錄功能
提供給 DA層
當資料欄位被標記為需要稽核時,當資料新增至Table或更新時,
自動寫入稽核紀錄
建立背景情境
背景:
假設 目前登入的使用者為Kirk
並且 ShopContext自動寫入稽核紀錄
[Given(@"目前登入的使用者為(.*)")]
public void 假設目前登入的使用者為(string name)
{
    this.httpContext = MockRepository.GenerateStub<HttpContextBase>();
    this.httpContext.User = MockRepository.GenerateStub<IPrincipal>();
    this.httpContext.User.Stub(i => i.Identity)
                         .Return(MockRepository.GenerateStub<IIdentity>());
    this.httpContext.User.Identity.Stub(i => i.Name)
                                  .Return(name);
}
[Given(@"ShopContext更新時會自動更新系統資訊")]
public void 假設ShopContext更新時會自動更新系統資訊()
{
    List<IPreActionHook> hooks = new List<IPreActionHook>();
    hooks.Add(new UpdateSystemInfoPreUpdateHook(this.httpContext));
    hooks.Add(new UpdateSystemInfoPreInsertHook(this.httpContext));
    this.shopContext = new ShopContext(hooks);
}
建立測試案例,驗證新增或更新敏感欄位時,稽核紀錄有沒有產生
場景: 當新增分類資料時,自動寫入一筆稽核紀錄,記錄新增值
假設 新增分類資料
| Name   |
| Fruits |	
那麼 資料庫中包含資料
| Name   | IsValid |
| Fruits | true    |
並且 稽核紀錄包含資料
| IdentifyName | OriginValue | NewValue |
| Category     |             | Fruits   |
場景: 當更新分類資料時,自動寫入一筆稽核紀錄,記錄舊值與新值
假設 新增分類資料
| Name   |
| Fruits |	
當 更新分類名字為Fruit
那麼 資料庫中包含資料
| Name  | IsValid |
| Fruit | true    |
並且 稽核紀錄包含資料
| IdentifyName | OriginValue | NewValue |
| Category     |             | Fruits   |
| Category     | Fruits      | Fruit    |
場景: 當新增商品資料時,自動寫入稽核紀錄,記錄新增值
假設 新增分類資料
| Id | Name   |
| 1  | Fruits |
並且 新增商品資料
| CategoryId | Name         | Price | Cost | ListingStartTime | ListingEndTime | SellingStartTime | SellingEndTime | IsValid |
| 1          | Hamburger    | 99    | 50   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
| 1          | Sandwitch    | 89    | 40   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
那麼 資料庫中包含資料
| Name   | IsValid |
| Fruits | true    |
並且 資料庫中包含商品資料
| Name      | Price | Cost | ListingStartTime | ListingEndTime | SellingStartTime | SellingEndTime | IsValid |
| Hamburger | 99    | 50   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
| Sandwitch | 89    | 40   | 2013-10-01       | 2014-10-01     | 2013-10-01       | 2014-10-01     | true    |
並且 稽核紀錄包含資料
| IdentifyName | OriginValue | NewValue |
| Category     |             | Fruits   |
| Product      |             | 99       |
| Product      |             | 50       |
| Product      |             | 89       |
| Product      |             | 40       |
測試程式碼
[Given(@"新增分類資料")]
public void 假設新增分類資料(Table table)
{
var categories = table.CreateSet();
foreach (var category in categories)
{
    this.shopContext.Categories.Add(category);
}
this.shopContext.SaveChanges();
}
[Given(@"新增商品資料")]
public void 假設新增商品資料(Table table)
{
var products = table.CreateSet();
foreach (var product in products)
{
    this.shopContext.Products.Add(product);
}
this.shopContext.SaveChanges();
}
[Then(@"資料庫中包含資料")]
public void 那麼資料庫中包含資料(Table table)
{
var categories = this.shopContext.Categories.ToList();
table.CompareToSet(categories);
}
[Then(@"資料庫中包含商品資料")]
public void 那麼資料庫中包含商品資料(Table table)
{
var products = this.shopContext.Products.ToList();
table.CompareToSet(products);
}
[When(@"更新分類名字為(.*)")]
public void 當更新分類資料(string name)
{
var category = this.shopContext.Categories.First();
category.Name = name;
this.shopContext.SaveChanges();
}
[Then(@"稽核紀錄包含資料")]
public void 那麼稽核紀錄包含資料(Table table)
{
var auditLogs = this.shopContext.AuditLogs.ToList();
table.CompareToSet(auditLogs);
}
執行測試,稽核紀錄有確實產生

※本日小結
透過在EntityFramework掛載Hook,我們直接在EntityFramework進行新增或刪除時多加了一組判斷,讓需要被稽核的欄位不會被遺漏,這個方法提供給大家做為一個系統稽核的雛形,而真正應用到企業場景的時候,還可以透過更多的技巧來微調,例如每個Table的稽核紀錄寫到各自的稽核Table,或是稽核資料的格式等等,大體都可以透過Reflection來達成,關於今天的內容,歡迎大家一起來討論喔 ^_^